Skip to content

[pull] main from tldraw:main#522

Merged
pull[bot] merged 10 commits intocode:mainfrom
tldraw:main
Apr 29, 2026
Merged

[pull] main from tldraw:main#522
pull[bot] merged 10 commits intocode:mainfrom
tldraw:main

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented Apr 29, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

mimecuvalo and others added 10 commits April 29, 2026 10:19
…erflowingToolbar (#8690)

In order to fix #8689, this PR reverts #8574, which removed the
`attributes` and `characterData` options from the `MutationObserver` in
`OverflowingToolbar`. With those options gone, switching directly
between two geo variants (e.g. pressing `R` then `O`) only re-renders
the affected `ToolbarItem` children, not `OverflowingToolbar` itself, so
its `useLayoutEffect` does not fire and `lastActiveOverflowItem` stays
pinned to the previous geo variant — leaving the wrong icon in the
toolbar slot.

Restoring the attribute/character-data observation makes those
tool-selection updates trigger an overflow recompute again, so the
toolbar slot swaps correctly when the active geo variant changes.

This re-introduces the intermittent `ResizeObserver loop completed with
undelivered notifications` warning from #8528. We'll revisit a better
fix that doesn't drop attribute observation — likely driving the
recompute from a reactive value at the parent level, or filtering
mutations to only those that actually change the visible toolbar layout.

### Change type

- [x] `bugfix`

### Test plan

1. Open the examples app or tldraw.com.
2. Press `R` to select the rectangle tool — the toolbar shows the
rectangle icon, selected.
3. Press `O` to select the oval tool.
4. Verify the toolbar slot swaps to the ellipse icon, with the selected
indicator still on it.
5. Verify toolbar overflow still works correctly when resizing the
window.

### Release notes

- Fix toolbar geo icon not swapping when switching directly between geo
variants (e.g. rectangle to ellipse).

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +2 / -0    |

Made with [Cursor](https://cursor.com)
In order to clear a tsserver warning in `apps/dotcom/client`, this PR
removes the `"plugins": [{ "name": "next" }]` entry from its
`tsconfig.json`. The dotcom client app builds with Vite (see
`vite.config.ts` and the `dev`/`build` scripts in `package.json`) and
has no `next` dependency, so the plugin reference is dead config.

### Change type

- [x] `other`

### Test plan

1. Open `apps/dotcom/client` in an editor with tsserver running and
confirm the "next" plugin warning is gone.
2. Run `yarn typecheck` from the repo root and confirm it still passes.

### Code changes

| Section        | LOC change |
| -------------- | ---------- |
| Config/tooling | +0 / -1    |
…ttributes (#8701)

In order to fix the `ResizeObserver loop completed with undelivered
notifications` warnings reported in #8528 without re-introducing the
toolbar geo-icon regression in #8689, this PR replaces the broad
`attributes: true` / `characterData: true` options on the
`OverflowingToolbar` `MutationObserver` with `attributeFilter:
['aria-pressed', 'data-value']` — the only attributes `onDomUpdate`
actually reads.

Closes #8528 

Background:

- #8574 fixed #8528 by dropping `attributes` and `characterData`
entirely. That broke #8689 because tool selection inside individual
`ToolbarItem` children no longer notified the parent toolbar (only the
affected children re-render, not `OverflowingToolbar` itself, so its
`useLayoutEffect` doesn't fire).
- #8690 reverted #8574, restoring #8689 but reopening #8528.
- This PR replaces both with a targeted filter, so attribute observation
still happens but only for the attributes that actually matter.

Why the loop happened in the first place:

1. **Hover-driven attribute churn**: tooltip/popover wrappers flip
`data-state`, `aria-describedby`, `aria-expanded` on toolbar descendants
on hover/focus. With unfiltered observation, every flip re-ran
`onDomUpdate`.
2. **Self-write feedback**: `onDomUpdate` writes `data-toolbar-visible`
on items. Unfiltered observation re-fired the observer on those writes,
creating a read→write→read cycle that the browser's ResizeObserver
guards against.

Why the geo-variant regression happened:

- Pressing `R` then `O` only re-renders the rectangle and oval
`ToolbarItem` children whose `useIsToolSelected` value changed — not
`OverflowingToolbar` itself. Without attribute observation, the
toolbar's `lastActiveOverflowItem` stayed pinned to the previous variant
and the wrong icon kept showing in the visible slot.

How `attributeFilter: ['aria-pressed', 'data-value']` resolves both:

| Trigger | Attribute changed | In filter? | Observer fires? |
| --- | --- | --- | --- |
| Hover button | `data-state`, `aria-describedby` | no | no |
| Open popover | `aria-expanded`, `data-state` | no | no |
| Our own visibility write | `data-toolbar-visible` | no | no |
| Active tool changes (incl. R then O) | `aria-pressed` | yes | yes |
| Tool slot identity changes | `data-value` | yes | yes |
| Add / remove a toolbar item | (childList) | yes | yes |
| Window / container resize | (ResizeObserver) | n/a | yes |

Both feedback paths into the loop are closed (hover-driven mutations and
self-writes are excluded), and the legitimate recompute path needed by
#8689 is preserved (`aria-pressed` flips still fire the observer).

`characterData` is dropped: toolbar buttons are icon-only, and any
genuine text-driven layout change (i18n locale switch) re-renders the
parent and runs the unconditional `useLayoutEffect` already.

### Change type

- [x] `bugfix`

### Test plan

1. Open the examples app or tldraw.com with the browser console open.
2. Move the pointer rapidly across the bottom toolbar buttons. Verify no
`ResizeObserver loop completed with undelivered notifications` warnings
appear.
3. Press `R` to select the rectangle tool, then `O` to select the oval
tool. Verify the toolbar slot swaps to the ellipse icon with the
selected indicator on it.
4. Resize the window so toolbar items overflow into the popover, then
back. Verify items move in and out of the overflow correctly.
5. Open the overflow popover, click a tool. Verify it gets pinned into
the visible toolbar slot.

### Release notes

- Fix intermittent `ResizeObserver loop` browser warnings when hovering
over toolbar buttons, while preserving correct icon swapping when
switching directly between geo variants.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +8 / -1    |

Made with [Cursor](https://cursor.com)
#8705)

In order to make the geo extensibility API self-consistent before its
first release, this PR renames the `customGeoStyles` option on
`GeoShapeUtil.configure()` back to `customGeoTypes`. Follow-up to #8543.

The original PR introduced `customGeoTypes`, then it was renamed to
`customGeoStyles` during review feedback. The rename was applied to the
option name, but several adjacent surfaces were missed:

- The exported `GeoTypeDefinition` interface (still uses "type")
- The internal `getCustomGeoType` helper and `customType` locals
- The `apps/examples/src/examples/shapes/tools/custom-geo-types/`
example folder, README title, and `CustomGeoTypesExample` component
- The release notes in `apps/docs/content/releases/next.mdx`, which
still document `customGeoTypes` (so the example as published would not
actually compile)

Re-aligning everything on "types" is the smaller change and matches the
conceptual model — `'rectangle' | 'ellipse' | 'rounded-rect'` are kinds
of geo, not styles. The "style" naming would have collided with the
existing `GeoShapeGeoStyle` `StyleProp` and the unrelated style panel
terminology.

This API has not shipped yet (it lives in `next.mdx`), so no released
consumers are affected.

### Change type

- [x] `api`

### Test plan

1. Open the `custom geo types` example.
2. Verify the rounded rectangle and cross shapes render, drag-create
from the toolbar, and appear in the geo style panel picker.

- [x] Unit tests (existing geo + style tests still pass)

### API changes

- Renamed `GeoShapeOptions.customGeoStyles` to
`GeoShapeOptions.customGeoTypes`. The option keys this map populates on
`GeoShapeGeoStyle` are unaffected.

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Core code       | +27 / -27  |
| Automated files | +1 / -1    |
| Documentation   | +11 / -11  |


Made with [Cursor](https://cursor.com)
In order to keep the minimap from getting stuck in a dragging state,
this PR ends the active drag when a right-click (contextmenu),
`pointerup`, or `pointercancel` happens during a minimap drag.
Previously, right-clicking the minimap mid-drag did not dispatch a
`pointerup`, so pointer capture was never released and `rPointing`
stayed true — subsequent pointer moves continued to recenter the camera
until the user clicked again.

The pointer being released is now tracked by id, so we release capture
on the same pointer that started the drag instead of synthesising a
release from the next event.

Closes #8693

### Change type

- [x] `bugfix`

### Test plan

1. Run `yarn dev` and open an example with content so the minimap is
interactive.
2. Press and hold the left mouse button on the minimap to start dragging
the viewport.
3. While still holding, right-click on the minimap and dismiss the
context menu.
4. Move the mouse over the canvas — the camera should not continue to
follow the pointer.
5. Repeat with a `pointercancel` (e.g. touch interruption) and confirm
the drag ends.

- [ ] Unit tests
- [ ] End to end tests

### Release notes

- Fix minimap getting stuck in a dragging state when right-clicked (or
when the pointer is cancelled) during a drag.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +23 / -7   |
…8702)

In order to promote the tldraw SDK to logged-out visitors of tldraw.com,
this PR adds a dismissible "Build with the tldraw SDK" overlay link in
the bottom-right of the editor — the same area used by the watermark.
The overlay sits above the watermark layer (`z-index:
calc(var(--tl-layer-watermark) + 1)`) so the watermark is not modified
or hidden, and reappears underneath if the overlay is dismissed. Closes
#8699.

Behavior:

- Renders only when Clerk's `useAuth().isSignedIn === false`, so
signed-in users (and the still-loading state) never see it.
- Dismissal is persisted in `localStorage` under
`tldraw-dotcom:anon-dotdev-link:dismissed`, so it stays dismissed across
reloads.
- Hidden under 700px to match the watermark's mobile breakpoint.
- Tracks the watermark's `data-debug` offset and RTL direction so it
stays aligned in those modes.

The component is rendered inside `<Tldraw>` in the local editor and the
multiplayer file editor, so it shows on both the signed-out local editor
and the signed-out file viewer.

### Change type

- [x] `feature`

### Test plan

1. Sign out and visit `localhost:3000` — overlay appears bottom-right.
2. Click the link — opens `tldraw.dev` in a new tab with the
`anon-overlay-link` UTM campaign.
3. Click the dismiss (X) button — overlay disappears.
4. Reload — overlay stays dismissed.
5. `localStorage.removeItem('tldraw-dotcom:anon-dotdev-link:dismissed')`
and reload — overlay reappears.
6. Sign in — overlay no longer renders.
7. Visit a shared file URL while signed out — overlay renders there too.
8. Resize below ~700px — overlay hides on mobile.
9. Toggle dark mode — colors track the tldraw theme tokens.

- [ ] Unit tests
- [ ] End to end tests

### Release notes

- Add a dismissible link to tldraw.dev in the bottom-right of the editor
for signed-out visitors on tldraw.com.
In order to keep the style panel in sync when the app switches between
light and dark mode, this PR subscribes the style panel color and font
item rendering to the editor's current theme and color mode. Closes
#8680.

### Change type

- [x] `bugfix`

### Test plan

1. Select black in the style panel.
2. Switch from light mode to dark mode and back.
3. Confirm the black color swatch changes to white in dark mode and back
to black in light mode without selecting another color.

- [x] Unit tests
- [ ] End to end tests

### Release notes

- Fix style panel color swatches updating when switching between light
and dark mode.

### API changes

- Added `'menu.color-theme'` to the `TLUiTranslationKey` union.

### Code changes

| Section         | LOC change |
| --------------- | ---------- |
| Core code       | +31 / -11  |
| Tests           | +46 / -0   |
| Automated files | +1 / -1    |
| Apps            | +3 / -1    |
Bumps production Zero view-syncer count from 5 to 7 machines.

Production VS machines are at 4-CPU saturation as we approach peak
traffic. Symptoms: pipeline-resets jumped from 0 to ~0.033/s starting
~12:45 UTC, CPU climbing 50% → 65%+, slow SQLite queries (100-450 ms) on
the small `_zero.changeLog2` table — slowness is contention-driven, not
table size (only 16,646 rows). Memory is fine (~2 GB used of 8 GB).

Routing is sticky-by-cookie (`fly_machine_id`), so adding machines
spreads independent client groups across more capacity. Follows #8666
which went 4 → 5.

### Change type

- [x] `other`

### Test plan

- After merge: `flyctl status -a production-zero-vs` shows 7 started
machines.
- Confirm load avg drops back below 4.0 per machine and pipeline-resets
metric returns toward 0.

### Release notes

- Internal: scale Zero view-syncer to 7 machines.
…lls (#8710)

In order to remove the white square visible at the base of the
collaborator cursor, this PR splits each cursor layer (shadow, white
outline, colored fill) into two separate `Path2D` objects — one for the
arrowhead, one for the tail — and fills them with two separate
`ctx.fill()` calls per layer. Closes #8692.

The previous implementation packed both subpaths into one `Path2D` per
layer and filled them in a single call. With the default `nonzero`
winding rule, the canvas renderer evaluates both subpaths together as
one compound shape; along the seam where the head and tail overlap, this
produced sub-pixel rasterisation artefacts that surfaced as a visible
white sliver/square at the base of the tail in some DPR + zoom
combinations. The original SVG-DOM implementation did not have this
problem because each `<path>` was rendered with its own fill operation,
with no compound-shape rasterisation across the shared boundary.

The split mirrors the original SVG's two-`<path>`-per-`<g>` layout and
matches its rendering behaviour exactly. The white tail's path
coordinates were also corrected from a 4×12 parallelogram to a 4×10 one
— a 1-unit perpendicular outline of the 2×8 colored fill tail — so the
white outline no longer extends past the colored fill.

### Change type

- [x] `bugfix`

### Test plan

1. Open a multiplayer example (e.g. `multiplayer-demo`) with another
collaborator.
2. Observe the remote collaborator's cursor at default zoom on a HiDPI
display.
3. Confirm the cursor renders as a single colored arrow with a thin
white outline; no white rectangle visible at the base of the tail.
4. Zoom in/out and pan; confirm the cursor stays clean at all zoom
levels.

- [x] Unit tests

### Release notes

- Fix a white square artefact that appeared at the base of remote
collaborator cursors.
In order to fix the bug where notes display a solid line at the bottom
edge in non-default dark mode themes (Ocean, Sunset, and any
developer-registered theme via #8410), this PR removes the `if
(colorMode === 'dark') hideShadows = true` shortcut in
`NoteShapeUtil.tsx`. With the shortcut gone, dark-mode notes render the
existing 3-layer box-shadow at normal zoom — the same path light mode
already uses. On the default-dark canvas the hardcoded `rgba(15, 23, 31,
...)` shadow is essentially invisible, so default-dark looks unchanged;
on lighter dark-theme backgrounds the shadow becomes visible and reads
correctly, with no more solid line.

Closes #8688.

### Why this is safe perf-wise

The dark-mode shortcut was a "free" perf bonus (skip an invisible
shadow), not load-bearing. The actual perf optimization is
`useEfficientZoomThreshold(0.25 / scale)`, which is mode-agnostic and
still active. After this change:

- Light mode, normal zoom: 3-layer box-shadow per note (unchanged).
- Light mode, low zoom: borderBottom (perf path, unchanged).
- Dark mode, normal zoom: 3-layer box-shadow per note — now matches
light mode. (Was: borderBottom.)
- Dark mode, low zoom: borderBottom (perf path, unchanged).

Dark-mode at normal zoom now hits the same compositor path as light-mode
at normal zoom, with the same shadow string. If light mode perf is
acceptable, dark mode will be too — they're equalized, not regressed.

### Change type

- [x] `bugfix`

### Test plan

1. Open the multiple-themes example
(`apps/examples/src/examples/ui/multiple-themes/`), switch to dark mode,
switch through Default / Ocean / Sunset, drop a note in each.
2. Confirm no conspicuous solid line under notes on Ocean / Sunset and
unchanged appearance on default-dark.
3. Verify low-zoom borderBottom still kicks in on dark mode (zoom out
past the threshold).

- [x] Unit tests

### Release notes

- Fix note shapes rendering a solid line at the bottom edge in
non-default dark mode themes.

### Code changes

| Section   | LOC change |
| --------- | ---------- |
| Core code | +2 / -3    |
@pull pull Bot locked and limited conversation to collaborators Apr 29, 2026
@pull pull Bot added the ⤵️ pull label Apr 29, 2026
@pull pull Bot merged commit 3fe7443 into code:main Apr 29, 2026
@pull pull Bot had a problem deploying to deploy-production April 29, 2026 15:13 Failure
@pull pull Bot had a problem deploying to deploy-staging April 29, 2026 15:13 Error
@pull pull Bot had a problem deploying to deploy-staging April 29, 2026 15:13 Error
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants